// app/api/partners/tbe/[sessionId]/documents/route.ts import { NextRequest, NextResponse } from "next/server" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import db from "@/db/db" import { rfqLastTbeDocumentReviews, rfqLastTbeSessions, rfqLastTbeHistory, rfqLastTbeVendorDocuments } from "@/db/schema" import { eq, and } from "drizzle-orm" import { writeFile, mkdir } from "fs/promises" import { createWriteStream } from "fs" import { pipeline } from "stream/promises" import path from "path" import { v4 as uuidv4 } from "uuid" // 1GB 파일 지원을 위한 설정 export const config = { api: { bodyParser: { sizeLimit: '1gb', }, responseLimit: false, }, } // 스트리밍으로 파일 저장 async function saveFileStream(file: File, filepath: string) { const stream = file.stream() const writeStream = createWriteStream(filepath) await pipeline(stream, writeStream) } // POST: TBE 문서 업로드 export async function POST(request: NextRequest, { params }: { params: { sessionId: string } }) { try { const session = await getServerSession(authOptions) if (!session?.user || session.user.domain !== "partners") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } const tbeSessionId = Number(params.sessionId) const formData = await request.formData() // ✅ 프런트 기frfqLastTbeVendorDocuments본값 'other' 등을 안전한 enum으로 매핑 const documentType = (formData.get("documentType") as string | undefined) const documentName = (formData.get("documentName") as string | undefined)?.trim() || "Untitled" const description = (formData.get("description") as string | undefined) || "" const file = formData.get("file") as File | null if (!file) { return NextResponse.json({ error: "파일이 필요합니다" }, { status: 400 }) } // 세션/권한 const tbeSession = await db.query.rfqLastTbeSessions.findFirst({ where: eq(rfqLastTbeSessions.id, tbeSessionId), with: { vendor: true }, }) if (!tbeSession) return NextResponse.json({ error: "TBE 세션을 찾을 수 없습니다" }, { status: 404 }) // 권한 체크: 회사 기준으로 통일 (위/아래 GET도 동일 기준을 권장) if (tbeSession.vendor?.id !== session.user.companyId) { return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 }) } // 저장 경로 const isDev = process.env.NODE_ENV === "development" const uploadDir = isDev ? path.join(process.cwd(), "public", "uploads", "tbe", String(tbeSessionId), "vendor") : path.join(process.env.NAS_PATH || "/nas", "uploads", "tbe", String(tbeSessionId), "vendor") await mkdir(uploadDir, { recursive: true }) const safeOriginal = file.name.replace(/[^a-zA-Z0-9.\-_\s]/g, "_") const filename = `${uuidv4()}_${safeOriginal}` const filepath = path.join(uploadDir, filename) try { if (file.size > 50 * 1024 * 1024) { await saveFileStream(file, filepath) } else { const buffer = Buffer.from(await file.arrayBuffer()) await writeFile(filepath, buffer) } } catch (e) { console.error("파일 저장 실패:", e) return NextResponse.json({ error: "파일 저장에 실패했습니다" }, { status: 500 }) } // 트랜잭션 const result = await db.transaction(async (tx) => { // 1) 벤더 업로드 문서 insert const [vendorDoc] = await tx .insert(rfqLastTbeVendorDocuments) .values({ tbeSessionId, documentType, // enum 매핑된 값 isResponseToReviewId: null, // 필요 시 formData에서 받아 세팅 fileName: filename, originalFileName: file.name, filePath: `/uploads/tbe/${tbeSessionId}/vendor/${filename}`, fileSize: Number(file.size), fileType: file.type || null, documentNo: null, revisionNo: null, issueDate: null, description, submittalRemarks: null, reviewRequired: true, reviewStatus: "pending", submittedBy: session.user.id, submittedAt: new Date(), reviewedBy: null, reviewedAt: null, reviewComments: null, }) .returning() // 2) (선택) 기존 리뷰 테이블에도 “벤더가 올린 검토대상 문서”로 남기고 싶다면 유지 // 필요 없다면 아래 블록은 제거 가능 const [documentReview] = await tx .insert(rfqLastTbeDocumentReviews) .values({ tbeSessionId, vendorAttachmentId:vendorDoc.id, documentSource: "vendor", documentType: documentType, // 동일 매핑 documentName: documentName, // UX 표시용 이름 reviewStatus: "미검토", reviewComments: description, createdAt: new Date(), updatedAt: new Date(), }) .returning() // 3) 세션 상태 전환 if (tbeSession.status === "준비중") { await tx .update(rfqLastTbeSessions) .set({ status: "진행중", actualStartDate: new Date(), updatedAt: new Date(), updatedBy: session.user.id, }) .where(eq(rfqLastTbeSessions.id, tbeSessionId)) } // 4) 이력 await tx.insert(rfqLastTbeHistory).values({ tbeSessionId, actionType: "document_review", changeDescription: `벤더 문서 업로드: ${documentName}`, changeDetails: { vendorDocumentId: vendorDoc.id, documentReviewId: documentReview.id, documentName: documentName, documentType: documentType, filePath: vendorDoc.filePath, }, performedBy: session.user.id, performedByType: "vendor", performedAt: new Date(), }) if (tbeSession.status === "준비중") { await tx.insert(rfqLastTbeHistory).values({ tbeSessionId, actionType: "status_change", previousStatus: "준비중", newStatus: "진행중", changeDescription: "벤더 문서 업로드로 인한 상태 변경", performedBy: session.user.id, performedByType: "vendor", performedAt: new Date(), }) } return { vendorDoc, documentReview, } }) return NextResponse.json({ success: true, data: { vendorDocumentId: result.vendorDoc.id, filePath: result.vendorDoc.filePath, originalFileName: result.vendorDoc.originalFileName, fileSize: result.vendorDoc.fileSize, fileType: result.vendorDoc.fileType, }, message: "문서가 성공적으로 업로드되었습니다", }) } catch (error) { console.error("TBE 문서 업로드 오류:", error) return NextResponse.json({ error: "문서 업로드에 실패했습니다" }, { status: 500 }) } } // GET: TBE 세션의 문서 목록 조회 export async function GET( request: NextRequest, { params }: { params: { sessionId: string } } ) { try { const session = await getServerSession(authOptions) if (!session?.user || session.user.domain !== "partners") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } const tbeSessionId = parseInt(params.sessionId) // TBE 세션 확인 및 권한 체크 const tbeSession = await db.query.rfqLastTbeSessions.findFirst({ where: eq(rfqLastTbeSessions.id, tbeSessionId), with: { vendor: true, documentReviews: { orderBy: (reviews, { desc }) => [desc(reviews.createdAt)], } } }) if (!tbeSession) { return NextResponse.json({ error: "TBE 세션을 찾을 수 없습니다" }, { status: 404 }) } // 벤더 권한 확인 if (tbeSession.vendor.userId !== session.user.id) { return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 }) } // PDFTron 코멘트 수 집계 (필요시) const documentsWithDetails = await Promise.all( tbeSession.documentReviews.map(async (doc) => { // PDFTron 코멘트 수 조회 const pdftronComments = await db.query.rfqLastTbePdftronComments.findFirst({ where: eq(rfqLastTbePdftronComments.documentReviewId, doc.id), }) return { ...doc, comments: pdftronComments?.commentSummary || { totalCount: 0, openCount: 0, }, } }) ) return NextResponse.json({ success: true, session: { id: tbeSession.id, sessionCode: tbeSession.sessionCode, sessionTitle: tbeSession.sessionTitle, sessionStatus: tbeSession.status, evaluationResult: tbeSession.evaluationResult, }, documents: documentsWithDetails, }) } catch (error) { console.error("문서 목록 조회 오류:", error) return NextResponse.json( { error: "문서 목록 조회에 실패했습니다" }, { status: 500 } ) } }